<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>Kinrox Blog</title>
    <link>https://kinrox.app/blog/</link>
    <description>Engineering deep dives from Kinrox: native Swift 6 &amp; SwiftUI macOS video player on libmpv, encrypted LAN iPhone remote, and standalone 196 MB Spotify player.</description>
    <language>en</language>
    <lastBuildDate>Sun, 08 Nov 2026 09:00:00 GMT</lastBuildDate>
    <atom:link href="https://kinrox.app/rss.xml" rel="self" type="application/rss+xml"/>
    <item>
      <title>Part 2: How Our &#x27;196 MB&#x27; Spotify Player Quietly Grew to 450 MB Overnight—Wait, 168 Dead WebSockets, SwiftUI&#x27;s Secret Tax, and Why foobar2000&#x27;s mmap Trick Still Rules</title>
      <link>https://kinrox.app/blog/why-our-196mb-macos-player-grew-to-450mb-swiftui-sqlite-mmap-foobar2000/</link>
      <guid isPermaLink="true">https://kinrox.app/blog/why-our-196mb-macos-player-grew-to-450mb-swiftui-sqlite-mmap-foobar2000/</guid>
      <pubDate>Sun, 08 Nov 2026 09:00:00 GMT</pubDate>
      <description>An 18-hour memory investigation on macOS: how we dissected foobar2000, IINA, and Audirvana, caught 168 zombie URLSessions red-handed in heap dumps, learned why 22-byte Spotify IDs defeat ARM64 SmallString, switched SQLite to zero-copy mmap, and tamed SwiftUI&#x27;s AttributeGraph without rewriting the app in AppKit.</description>
      <content:encoded><![CDATA[<figure class="article-figure"><img src="https://kinrox.app/assets/img/blog/kinrox-spotify-music.webp" alt="Kinrox Spotify Music mode: dissecting memory footprint after an 18-hour session on macOS" loading="lazy"><figcaption>Kinrox Spotify Music mode: dissecting memory footprint after an 18-hour session on macOS</figcaption></figure>
<p>Yesterday, I published <a href="https://kinrox.app/blog/spotify-196mb-video-player-iphone-remote-matrix-screensaver/">Part 1 of the Kinrox story</a>, celebrating victory. We had built a native macOS video and Spotify player in Swift 6 and SwiftUI, replaced the 2 GB Electron-based <code>Spotify.app</code> with Spotify&#x27;s official headless <code>soloist</code> binary (<code>27 MiB</code> RAM), squashed an eager <code>VStack</code> in the sidebar, and watched Activity Monitor settle at a proud <strong>196 MB</strong>.</p>
<p>Champagne corks popped. I plugged my MacBook into the projector, queued up music and playlists, clicked around our new v3 Similar &amp; Discovery engine, and left <a href="https://kinrox.app/">Kinrox</a> (<a href="https://github.com/awaik/Kinrox">GitHub</a>) running overnight.</p>
<p>Seventeen and a half hours later, I opened Activity Monitor.</p>
<p><strong>450.75 MiB.</strong> With a lifetime peak of <strong>743.1 MiB</strong>.</p>
<blockquote><p>Wait a second. Our entire SQLite music database on disk is a couple of megabytes. The <code>soloist</code> audio engine is still sitting politely at <code>29.11 MiB</code>. Our C watchdog reaper (<code>KinroxSoloistHost</code>) is <code>0.92 MiB</code>. Why on earth is the main <code>Kinrox</code> UI process eating <strong>417 MiB</strong> after 17.5 hours?!</p></blockquote>
<p>Even worse: when we ran macOS&#x27;s <code>leaks</code> tool against the live process (<code>PID 45801</code>), it proudly reported: <strong>2 leaks totaling 112 bytes</strong>.</p>
<p>Let that sink in. Not 112 megabytes—<strong>112 bytes</strong>. According to <code>leaks</code>, our app was squeaky clean. Yet over 400 MiB of physical RAM was locked up inside our process.</p>
<p>What followed was the most fascinating rabbit hole of the entire project. We dissected how legendary players (<code>foobar2000</code>, <code>Deadbeef</code>, <code>Cog</code>, <code>Audirvana</code>, <code>IINA</code>, <code>mpv</code>) manage memory, took an X-ray of our live process with <code>vmmap</code> and <code>heap</code>, seriously debated throwing <code>SwiftUI</code> out the window in favor of pure <code>AppKit</code>, discovered an operating-system &quot;legal cheat code&quot; (<code>mmap</code>), and caught five completely different memory hogs hiding in plain sight.</p>
<p>Grab some coffee. Here is the full detective story.</p>
<h2 id="first-stop-how-do-real-low-memory-players-do-it">First Stop: How Do &quot;Real&quot; Low-Memory Players Do It?</h2>
<p>Before touching a single line of code, we asked a basic question: how do cult-classic players manage huge music libraries of 50,000 to 100,000 tracks while using less RAM than a single browser tab?</p>
<p>When you look under the hood of reference players across macOS, Windows, and Linux, their architectures split into four distinct schools:</p>
<div class="article-table-wrap"><table class="article-table"><thead><tr><th>Player</th><th>Stack &amp; UI</th><th>How It Stores &amp; Renders the Library</th><th>Typical RAM Footprint</th></tr></thead><tbody><tr><td><strong>mpv</strong></td><td>Pure C, no GUI (OSD only)</td><td>Zero library state in memory; only the active file&#x27;s demuxer and hardware decoder frames</td><td><strong>35–80 MB</strong></td></tr><tr><td><strong>foobar2000 / DeaDBeeF</strong></td><td>C/C++ + native OS table (<code>NSTableView</code> / Win32)</td><td>Flat binary index mapped from disk via <code>mmap</code> + aggressive <strong>String Interning</strong></td><td><strong>25–50 MB</strong></td></tr><tr><td><strong>IINA</strong></td><td>Swift/AppKit + <code>libmpv</code></td><td>Native <code>NSWindow</code> + <code>CAOpenGLLayer</code> + cell-reusing <code>NSTableView</code></td><td><strong>90–160 MB</strong></td></tr><tr><td><strong>Audirvana / Roon</strong></td><td>Heavy desktop UI + custom audio engine</td><td>Full object graphs in heap + pre-buffered decoded PCM audio in RAM</td><td><strong>300–900+ MB</strong></td></tr></tbody></table></div>
<p>Looking at that table, two revelations jump right out.</p>
<h3 id="revelation-1-why-mmap-is-a-legal-ram-cheat-code-on-macos">Revelation #1: Why <code>mmap</code> Is a Legal RAM Cheat Code on macOS</h3>
<p>At first glance, &quot;mapping the database into memory (<code>mmap</code>)&quot; sounds like it should *increase* RAM usage. Paradoxically, on Darwin (macOS), proper <code>mmap</code> uses <strong>several times less physical RAM</strong> than loading Swift/C++ objects into the heap—and changes what the user sees in Activity Monitor&#x27;s <strong>Memory</strong> column.</p>
<p>Here is what happens at the XNU kernel level:</p>
<ol><li><strong>Dirty (Private) Memory vs. Clean (Page Cache) Memory.</strong> Whenever your app reads SQLite rows and allocates an array of Swift structs or classes (<code>[MusicTrack]</code>, <code>String</code> buffers, dictionaries) on the heap via <code>malloc</code>, macOS marks those pages as <strong>Dirty / Private Memory</strong>. They belong exclusively to your process, count 100% toward your <strong>Physical Footprint</strong> in Activity Monitor, and if RAM gets tight, macOS has to burn CPU compressing them in the <code>Memory Compressor</code> or swapping them to SSD.</li><li><strong>Double Caching with Standard SQLite <code>read()</code>.</strong> When SQLite reads a database using standard <code>read()</code> syscalls, the macOS kernel first caches that disk page in the Unified Buffer Cache, and then copies those bytes into SQLite&#x27;s private user-space page cache (<code>PRAGMA cache_size</code>). You pay for the same data in RAM twice.</li><li><strong>Zero-Copy <code>mmap</code> (<code>PRAGMA mmap_size</code>).</strong> When you enable <code>mmap</code> in SQLite (<code>PRAGMA mmap_size = 67108864;</code> for 64 MB) and shrink SQLite&#x27;s private heap cache (<code>PRAGMA cache_size = -2000;</code> for 2 MB), the kernel simply maps virtual addresses in your process directly to the file pages in the Unified Buffer Cache. <strong>At startup, 0 bytes of physical RAM are allocated.</strong> As your app reads pages, the CPU triggers minor page faults, reading 16 KB pages on Apple Silicon straight from NVMe. Most importantly, because those pages match the file on disk byte-for-byte, macOS classifies them as <strong>Clean / Purgeable Page Cache</strong>—they don&#x27;t bloat your app&#x27;s private memory footprint, and if you launch a heavy game or Xcode build, the kernel can evict them in zero microseconds without touching swap.</li></ol>
<h3 id="revelation-2-string-interning-and-why-22-byte-spotify-ids-defeat-arm64-swift">Revelation #2: String Interning and Why 22-Byte Spotify IDs Defeat ARM64 Swift</h3>
<p>Look at how <code>foobar2000</code> stores 100,000 tracks. If you have 50 albums by the same artist, how many times does <code>foobar2000</code> store the artist&#x27;s name or album title in memory? <strong>Once</strong>, in a deduplicated string pool, while tracks reference it by a 4-byte integer offset.</p>
<p>Now look at how a naive Swift model stores a Spotify library:</p>
<ul><li>On 64-bit ARM (<code>arm64</code>), Swift&#x27;s inline <code>SmallString</code> optimization can hold at most <strong>15 UTF-8 bytes</strong> inside the 16-byte <code>String</code> struct value.</li><li>A Spotify Base62 ID (<code>4uLU6hMCjMI75M1A2tKUQC</code>) is <strong>22 bytes</strong> long.</li><li>A Spotify URI (<code>spotify:track:4uLU6hMCjMI75M1A2tKUQC</code>) is <strong>36 bytes</strong> long.</li></ul>
<p>Every single Spotify ID and URI exceeds 15 bytes by just enough to miss <code>SmallString</code> and trigger a separate heap allocation (<code>Swift.StringStorage</code>, rounded up to malloc bucket sizes)! If an album has 15 tracks and you decode each track from JSON independently, all 15 tracks allocate their own separate heap buffers for <code>albumID</code>, <code>albumTitle</code>, <code>artist.id</code>, <code>artist.uri</code>, <code>artist.name</code>, and their own 48-byte <code>[MusicArtist]</code> array buffer—even though they are identical!</p>
<p>Sure enough, when we looked at our heap dump (<code>heap-selected.txt</code>), our catalog had only <strong>488 unique artists</strong>, yet there were <strong>3,467 separate <code>Array&lt;MusicArtist&gt;</code> buffers</strong> (<code>492 KB</code>) sitting in the heap.</p>
<h2 id="opening-the-x-ray-vmmap-heap-where-did-417-mib-actually-go">Opening the X-Ray (<code>vmmap</code> &amp; <code>heap</code>): Where Did 417 MiB Actually Go?</h2>
<p>Before blaming everything on strings or SQLite, we needed cold, hard numbers from the 17.5-hour process (<code>PID 45801</code>).</p>
<p>Wait—remember how <code>leaks</code> said there were only <strong>112 bytes</strong> of leaked memory? Why does <code>leaks</code> say zero when Activity Monitor says <code>417 MiB</code>? Because <code>leaks</code> only looks for <strong>unreachable pointers</strong> (malloc blocks with no reference pointing to them). If you accidentally keep objects alive inside an array, a cache, a view graph, or an unclosed network session, <code>leaks</code> sees a valid pointer and says: *&quot;Looks totally intentional to me!&quot;*</p>
<p>When we ran <code>vmmap -summary 45801</code> and <code>heap 45801</code>, the real picture emerged—and it was wild:</p>
<div class="article-table-wrap"><table class="article-table"><thead><tr><th>Memory Region / Category (<code>PID 45801</code>)</th><th>Footprint / Dirty Size</th><th>What It Actually Was</th></tr></thead><tbody><tr><td><code>IOAccelerator</code> + <code>IOSurface</code> (GPU / VRAM)</td><td><strong>150.6 MiB</strong> (<code>119.6M</code> + <code>31.0M</code>)</td><td>Metal/OpenGL textures, window backing stores, and <code>libmpv</code> <code>CAOpenGLLayer</code> framebuffers</td></tr><tr><td><code>Shared Memory</code></td><td><strong>82.0 MiB</strong></td><td>WindowServer &amp; CoreAnimation layer surfaces</td></tr><tr><td><code>MALLOC</code> Active Heap</td><td><strong>75.7 MiB</strong> (231,806 blocks)</td><td>Live Swift objects, SwiftUI <code>AttributeGraph</code>, caches, and network sessions</td></tr><tr><td><code>MALLOC</code> Zone Fragmentation</td><td><strong>68.9 MiB</strong> (<code>48%</code> fragmented!)</td><td>&quot;Swiss cheese&quot; memory pages left behind after a 743 MiB UI spike</td></tr><tr><td>Stacks, <code>.dylib</code> data, SQLite Page Cache</td><td><strong>~39.7 MiB</strong></td><td>Thread stacks, system frameworks, and SQLite</td></tr><tr><td><strong>Total Main Process Footprint</strong></td><td><strong>416.9 MiB</strong></td><td>Plus ~33.8 MiB for <code>soloist</code> + <code>reaper</code> = <strong>450.75 MiB total</strong></td></tr></tbody></table></div>
<p>That table immediately killed our naive assumptions. And then we scrolled down <code>heap-top.txt</code>—and froze.</p>
<h2 id="smoking-gun-1-168-dead-websockets-sitting-in-the-closet">Smoking Gun #1: 168 Dead WebSockets Sitting in the Closet</h2>
<p>Right in the middle of <code>heap-top.txt</code>, sorted by object count, sat this incriminating cluster:</p>
<ul><li><code>168</code> instances of <code>__NSURLSessionWebSocketTask</code> (<code>172 KB</code>)</li><li><code>169</code> instances of <code>__NSURLSessionLocal</code> (<code>76 KB</code>)</li><li><code>169</code> instances of <code>std::__shared_ptr_emplace&lt;NWIOConnection&gt;</code> (<code>130 KB</code>)</li><li><code>840</code> instances of <code>NSURLRequestInternal</code> (<code>215 KB</code>, <code>168 × 5</code>)</li><li><code>1,009</code> instances of <code>CFHTTPMessage</code> (<code>280 KB</code>, <code>168 × 6 + 1</code>)</li><li><code>1,194</code> serial <code>dispatch_queue_t</code> queues (<code>153 KB</code>, <code>168 × 7</code>)!</li></ul>
<p>Where did the magic number <strong>168</strong> come from?!</p>
<p>Over that 17.5-hour session, the player had played <strong>168 tracks</strong>!</p>
<p>Let&#x27;s be precise about what the numbers show—and separate <strong>observed object counts</strong>, <strong>source-level cause</strong>, and <strong>measured effect</strong>:</p>
<ol><li><strong>What <code>heap-top.txt</code> actually measured:</strong> Across those 168 track launches, the direct heap size of those <code>URLSession</code> / WebSocket / <code>NWIOConnection</code> / queue allocations was only around <strong>1–2 MiB</strong> in total—so they did *not* account for the bulk of the 416.9 MiB footprint on their own, but their counts grew strictly in lockstep (<code>168×</code>) with the number of tracks played.</li><li><strong>What <code>SoloistProcessSupervisor.swift</code> was doing in <code>v0.5.5</code> (<code>HEAD</code>):</strong> In <code>0.5.0</code>, we switched <code>soloist</code> to <code>--single-track</code> mode so it spawns a fresh process for each song and connects over a local WebSocket. On every single track launch, <code>launch()</code> called <code>URLSession(configuration: settings, delegate: SoloistSocketDelegate(), delegateQueue: nil)</code>—allocating a <strong>brand-new <code>URLSession</code></strong> for that one song. On teardown, <code>stop()</code> *did* call <code>socket?.cancel(with: .goingAway, reason: nil)</code> and <code>session?.invalidateAndCancel()</code> (and nilled out both references), but it did <strong>not</strong> call <code>(socket as URLSessionTask).cancel()</code> before terminating the <code>soloist</code> process.</li><li><strong>Why those objects lingered and how we fixed it:</strong> Because the live process wasn&#x27;t running under <code>MallocStackLogging</code>, <code>heap</code> alone doesn&#x27;t give a retain-cycle backtrace inside <code>CFNetwork</code> proving why 168 already-invalidated <code>__NSURLSessionLocal</code> and <code>__NSURLSessionWebSocketTask</code> instances remained resident after <code>cancel(with: .goingAway)</code> and <code>invalidateAndCancel()</code> raced with local process termination. Regardless, creating and tearing down a fresh <code>URLSession</code> on every song was completely unnecessary. Reusing <strong>one shared <code>URLSession</code></strong> across track launches (<code>activeSession()</code>, invalidated only in <code>close()</code>) and calling <code>(activeSocket as URLSessionTask).cancel()</code> immediately in <code>stop()</code> eliminates per-track <code>URLSession</code> churn by construction.</li></ol>
<p>While we were inside <code>SoloistProcessSupervisor</code>, we spotted a second per-track cost: before launching each track, verification read the entire multi-megabyte <code>soloist</code> binary into a <code>Data</code> buffer to compute its SHA-256 hash and spawned <code>/usr/bin/codesign</code> to verify its signature—even when the binary on disk hadn&#x27;t changed since the previous song 3 minutes ago! Caching a nanosecond-precision <code>lstat()</code> stamp (<code>dev</code>, <code>ino</code>, <code>size</code>, <code>mtime</code>, <code>ctime</code>, <code>btime</code>, <code>digest</code>, <code>hostRequirement</code>)—and verifying that the stamp before and after hashing/signing matches—skips redundant file reads and <code>codesign</code> subprocesses on unchanged binaries while still re-verifying immediately if anything on disk changes.</p>
<h2 id="smoking-gun-2-paying-the-video-player-s-gpu-tax-while-listening-to-music">Smoking Gun #2: Paying the Video Player&#x27;s GPU Tax While Listening to Music</h2>
<p>Next, why was <code>IOAccelerator + IOSurface</code> holding onto <strong>150.6 MiB</strong> of graphics memory when we were only listening to Spotify in Music mode?</p>
<p>We checked <code>PlayerRootView.swift</code> and <code>PlayerWindowController.swift</code>:</p>
<ul><li>On app launch, Kinrox unconditionally initialized <code>MPVPlayer</code>, its OpenGL render context (<code>MPVRenderContext</code> + CGL), and <code>VideoLayer</code> (<code>CAOpenGLLayer</code>).</li><li>When you switched the top mode picker from <code>Video</code> to <code>Music</code>, <code>PlayerRootView</code> didn&#x27;t unmount the video view—it just set <code>.opacity(0)</code> on <code>PlayerView</code>!</li><li>To macOS and CoreAnimation, a <code>CAOpenGLLayer</code> with <code>.opacity(0)</code> attached to a live <code>NSWindow</code> is still a real, full-window Retina OpenGL surface (<code>isAsynchronous = true</code>, <code>contentsScale = 2.0</code>) holding multi-megabyte <code>IOSurface</code> and <code>IOAccelerator</code> framebuffers behind the scenes.</li></ul>
<p>We fixed this cleanly: when <code>session.mode == .music</code> and no video file is loaded (<code>!model.hasMedia &amp;&amp; !model.isLoading</code>), <code>PlayerView</code> now unmounts <code>VideoHostView</code> and <code>WelcomeView</code>, and <code>VideoHostView.viewDidMoveToWindow()</code> calls <code>videoLayer.releaseBacking()</code> (<code>frame = .zero</code>, <code>contents = nil</code>) as soon as it leaves the window—freeing the idle OpenGL backing surfaces while keeping mode-switching and traffic-light buttons instant.</p>
<h2 id="smoking-gun-3-the-big-question-should-we-ditch-swiftui-for-appkit">Smoking Gun #3: The Big Question—Should We Ditch SwiftUI for AppKit?</h2>
<p>Now we arrived at the heart of the investigation: <strong>75.7 MiB of live heap, 68.9 MiB of malloc fragmentation, and that terrifying 743.1 MiB lifetime peak.</strong></p>
<p>Why did the heap spike to 743 MiB during active browsing and leave 68.9 MiB of fragmented &quot;Swiss cheese&quot; pages behind?</p>
<p>The answer is <strong>SwiftUI&#x27;s per-row tax in lists and workspaces</strong>:</p>
<ul><li>In pure AppKit (<code>NSTableView</code>), if your playlist has 5,000 tracks and your screen fits 30 rows, AppKit creates <strong>35 <code>NSTableCellView</code> instances</strong> and recycles them in a ring buffer as you scroll.</li><li>In SwiftUI (<code>ScrollView</code> + <code>LazyVStack</code>), views are not recycled like <code>NSTableCellView</code>. As you scroll through hundreds of tracks, expand album cards, or browse Similar &amp; Discovery recommendations, SwiftUI&#x27;s <code>AttributeGraph</code> builds and retains dependency nodes, layout caches (<code>DisplayList.ViewUpdater.ViewCache.map</code> alone was <strong>4.87 MiB</strong> in our heap dump!), and modifier storage for every realized row.</li></ul>
<p>And we had accidentally made SwiftUI&#x27;s job 10 times harder in four places:</p>
<ol><li><strong>Unconditional <code>.popover</code> and <code>.help</code> on every Artist and Album link.</strong> In <code>0.5.2</code>, we made the heart button&#x27;s popover conditional—but in <code>TrackMetadataPopovers.swift</code>, every single <code>TrackArtistLink</code> and <code>TrackAlbumLink</code> in the track table still attached <code>.popover(isPresented:)</code> and <code>.help(...)</code> unconditionally! Even when closed, SwiftUI allocated presentation state (<code>PopoverPresentationModifier</code>, <code>HelpStyleConfiguration</code>) for every visible cell. Worse, <code>MusicViewModel</code>&#x27;s popover and focus properties lived directly on the root <code>@Observable</code> <code>MusicViewModel</code>—meaning every link in every row subscribed to the root view model!</li><li><strong>Hidden eager <code>VStack</code>s inside lazy screens.</strong> While our main <code>TrackTable</code> used <code>LazyVStack</code>, our new v3 <code>DiscoveryWorkspaceView.tracksSection</code> wrapped all recommended tracks inside an eager <strong><code>VStack</code></strong> nested inside the outer scroll view! And in <code>AlbumsWorkspaceView</code>, expanding an album card rendered all of its tracks inside an eager <strong><code>VStack</code></strong>.</li><li><strong>Two extra copies of the catalog in memory.</strong> Whenever <code>SpotifyLibraryStore</code> saved updates to <code>SpotifyCacheStore</code>, the cache actor kept <code>private var previous: SpotifyCachedLibrary?</code>—a complete second copy of <code>MusicCatalog</code> and <code>LibrarySnapshot</code> in RAM just to diff what changed on the next SQLite write! Meanwhile, repeated searches and Discovery runs merged disposable tracks and albums into <code>SpotifyLibraryStore.catalog</code> without ever pruning evicted search/discovery items.</li></ol>
<p>At this point, we asked ourselves: <strong>Should we throw SwiftUI out and rewrite the UI in AppKit?</strong></p>
<p>Here is the honest engineering math: rewriting the entire app in AppKit would burn weeks of work and break velocity on 90% of the UI (the player chrome, Settings, onboarding, popovers, and the Matrix screensaver) that doesn&#x27;t have 5,000 rows. Instead, we applied surgical fixes right where the memory went:</p>
<ul><li><strong>Isolated <code>@Observable</code> state (<code>MusicPopoverState</code> &amp; <code>MusicFocusState</code>).</strong> We extracted popover and focus state out of <code>MusicViewModel</code> into tiny dedicated <code>@Observable</code> objects (<code>@ObservationIgnored</code> on the main model), and attached <code>.popover</code> on <code>TrackArtistLink</code> / <code>TrackAlbumLink</code> <strong>only</strong> when <code>isPresented == true</code>, plus scoped <code>.help</code> tooltips to hovered rows (<code>hoverHelp</code>).</li><li><strong>Replaced every inner <code>VStack</code> with <code>LazyVStack</code></strong> in <code>DiscoveryWorkspaceView</code> and expanded <code>AlbumWorkspaceCard</code> views.</li><li><strong>Switched <code>SpotifyCacheDatabase</code> to <code>WAL</code> + 64 MiB <code>mmap</code> (<code>PRAGMA mmap_size = 67108864</code>, <code>PRAGMA cache_size = -2000</code>)</strong> and wrote a zero-intermediate-copy batch streamer (<code>catalogBatches</code>) that reads JSON slices directly from SQLite&#x27;s <code>mmap</code> pointers (<code>sqlite3_column_blob</code>) into framed JSON batch buffers without allocating thousands of <code>[Data?]</code> row arrays.</li><li><strong>Replaced <code>SpotifyCacheStore.previous</code> with <code>SpotifyCacheSummary</code></strong>—a lightweight dictionary of 64-bit <code>FNV-1a</code> entity digests (<code>~235 KB</code> instead of a full second <code>MusicCatalog</code> copy in RAM).</li><li><strong>Added String &amp; Artist Array Interning in <code>MusicCatalog.merge</code></strong> (so all tracks on the same album or by the same artist share the exact same canonical <code>StringStorage</code> and <code>[MusicArtist]</code> buffer via Swift copy-on-write) and <strong>automatic pruning of disposable search &amp; discovery entities</strong> (<code>pruneDisposableCatalog</code>).</li></ul>
<h2 id="where-we-are-now-before-vs-after">Where We Are Now: Before vs. After</h2>
<p>All five steps are now live and verified across <strong>362 automated tests</strong> (<code>43</code> in <code>KinroxMusic</code> + <code>319</code> in <code>PlayerMacTests</code>):</p>
<div class="article-table-wrap"><table class="article-table"><thead><tr><th>Bottleneck Discovered in 17.5h Session</th><th>Observed in v0.5.5 Baseline</th><th>Structural Change Implemented</th></tr></thead><tbody><tr><td><code>SoloistProcessSupervisor</code> per-track session &amp; binary verification</td><td><strong>168 <code>__NSURLSessionWebSocketTask</code></strong>, 169 <code>__NSURLSessionLocal</code>, 169 <code>NWIOConnection</code>, <strong>1,194 serial queues</strong> (~1–2 MiB total in <code>heap-top.txt</code> despite <code>session?.invalidateAndCancel()</code> in <code>stop()</code>), plus full binary read + <code>codesign</code> per track</td><td><strong>1 shared <code>URLSession</code></strong> (<code>activeSession()</code>, invalidated in <code>close()</code>), immediate <code>URLSessionTask.cancel()</code>, and nanosecond pre/post-verified <code>lstat()</code> stamp</td></tr><tr><td>Idle <code>VideoLayer</code> OpenGL surface in Music mode</td><td>Mounted with <code>.opacity(0)</code> on the main window, holding <code>CAOpenGLLayer</code> / <code>IOSurface</code> buffers</td><td>Unmounted when no video is loaded; <code>videoLayer.releaseBacking()</code> frees layer backing on detach</td></tr><tr><td>Duplicate <code>[MusicArtist]</code> arrays &amp; &gt;15-byte Spotify ID/URI strings</td><td><strong>3,467 separate <code>[MusicArtist]</code> buffers</strong> for 488 artists; duplicate heap strings per track</td><td>Canonical <strong>String &amp; <code>[MusicArtist]</code> interning</strong> inside <code>MusicCatalog.merge</code></td></tr><tr><td>SQLite cache storage &amp; incremental diffing</td><td>Standard buffer cache + <code>[[Data?]]</code> row copies + full duplicate <code>SpotifyCachedLibrary</code> in <code>SpotifyCacheStore.previous</code></td><td><strong>64 MiB SQLite <code>mmap</code></strong> (Clean Page Cache), 2 MiB private cache, streaming <code>catalogBatches</code>, and 64-bit <code>FNV-1a</code> digest diffing</td></tr><tr><td>SwiftUI rows, popovers &amp; Discovery lists</td><td>Unconditional <code>.popover</code>/<code>.help</code> per link, root <code>MusicViewModel</code> observation, eager inner <code>VStack</code> in Discovery &amp; expanded albums</td><td>Conditional <code>.popover</code> (<code>if isPresented</code>), <code>.hoverHelp</code>, isolated <code>MusicPopoverState</code>/<code>MusicFocusState</code>, and full <code>LazyVStack</code></td></tr></tbody></table></div>
<h3 id="live-result-after-the-changes-about-158-mb-total">Live Result After the Changes: About 158 MB Total</h3>
<p>On <strong>November 8, 2026, at approximately 21:04 (UTC+07:00)</strong>, we measured the running installation with macOS <code>footprint</code>, including every running Soloist and helper process:</p>
<div class="article-table-wrap"><table class="article-table"><thead><tr><th>Process</th><th>Physical Footprint</th></tr></thead><tbody><tr><td>Kinrox</td><td><strong>123 MB</strong></td></tr><tr><td>Soloist and helper processes combined</td><td><strong>About 35 MB</strong></td></tr><tr><td><strong>Combined footprint reported by macOS</strong></td><td><strong>158 MB</strong></td></tr></tbody></table></div>
<p>The main Kinrox process had a lifetime peak of <strong>153 MB</strong> in this run; that peak excludes Soloist and helpers. The combined current footprint is roughly <strong>65% below</strong> the earlier <strong>450.75 MiB</strong> observation. This is a live snapshot after the changes, rather than a repeat of the earlier 17.5-hour session under identical conditions: it demonstrates the observed result, not a maximum or a long-session guarantee. We did not record the playback state or library size for this snapshot.</p>
<h2 id="five-lessons-from-this-investigation">Five Lessons From This Investigation</h2>
<ol><li><strong><code>leaks</code> showing 0 leaks does not mean you don&#x27;t have unwanted retention.</strong> Reachable objects inside <code>CFNetwork</code>, SwiftUI view caches, and growing catalog dictionaries all have valid pointers—<code>leaks</code> will bless them while your process footprint grows over an 18-hour session. Always inspect <code>heap &lt;PID&gt;</code> object counts alongside <code>vmmap -summary</code>, and separate what is directly measured from what requires allocation backtraces.</li><li><strong>SwiftUI&#x27;s biggest hidden cost is eager modifiers and observation fan-out inside lists.</strong> A single unconditional <code>.popover</code> or <code>.help</code> inside a table cell—or an eager <code>VStack</code> nested inside a <code>ScrollView</code>—multiplies across every row and leaves tens of megabytes of <code>AttributeGraph</code> and malloc fragmentation behind.</li><li><strong>Know your platform&#x27;s inline string limit.</strong> On <code>arm64</code> Swift, any <code>String</code> over <strong>15 UTF-8 bytes</strong> (including every 22-byte Spotify ID and 36-byte URI) allocates on the heap. Interning shared identifiers and titles during catalog merge gives you <code>foobar2000</code>-style deduplication for free via Swift&#x27;s copy-on-write strings.</li><li><strong>Use <code>PRAGMA mmap_size</code> in SQLite on macOS.</strong> Mapping read-heavy SQLite caches into Clean Page Cache while capping <code>PRAGMA cache_size</code> prevents double-buffering in private dirty RAM.</li><li><strong>Measure before rewriting your UI framework.</strong> You rarely need to abandon SwiftUI for the entire app—fixing lifecycle leaks, unmounting inactive GPU layers, interning model strings, and isolating <code>@Observable</code> row state solves the real bottlenecks in days instead of months.</li></ol>
<p>The complete code and full investigation report (<code>docs/RESEARCHES/2026-11-08_12-17_Kinrox-memory-reduction-research.md</code>) are open in the repository:</p>
<ul><li>Website &amp; Mac DMG download: <a href="https://kinrox.app/">https://kinrox.app/</a></li><li>GitHub repository: <a href="https://github.com/awaik/Kinrox">https://github.com/awaik/Kinrox</a></li></ul>]]></content:encoded>
    </item>
    <item>
      <title>What Happens When a Developer Has Unused AI Tokens Left: A 196 MB Spotify Player (Instead of 2 GB), a Mac Video Player with an iPhone Remote, and—Most Importantly—a Matrix Screensaver</title>
      <link>https://kinrox.app/blog/spotify-196mb-video-player-iphone-remote-matrix-screensaver/</link>
      <guid isPermaLink="true">https://kinrox.app/blog/spotify-196mb-video-player-iphone-remote-matrix-screensaver/</guid>
      <pubDate>Sat, 07 Nov 2026 09:00:00 GMT</pubDate>
      <description>How leftover token quota turned into Kinrox: a native Swift 6 &amp; SwiftUI macOS video player on libmpv with an encrypted iPhone remote, a standalone 196 MB Spotify player powered by Spotify Soloist, a C watchdog reaper, an AttributeGraph deep dive, and a 30 fps Matrix screensaver.</description>
      <content:encoded><![CDATA[<figure class="article-figure"><img src="https://kinrox.app/assets/img/blog/kinrox-blog-cover.webp" alt="Kinrox: native macOS Spotify player, video player with an iPhone remote, and the Matrix screensaver" loading="lazy"><figcaption>Kinrox: native macOS Spotify player, video player with an iPhone remote, and the Matrix screensaver</figcaption></figure>
<p>You know that dangerous moment at the end of a billing cycle when all your main project tasks are wrapped up, but you still have unused AI tokens burning a hole in your balance? A normal person goes to sleep. A developer with leftover quota starts building unhinged side projects.</p>
<p><a href="https://kinrox.app/">Kinrox</a> (<a href="https://github.com/awaik/Kinrox">GitHub</a>) was born right out of that exact state—the intersection of two everyday annoyances and one non-negotiable aesthetic requirement: build a couch-friendly Mac video player with an iPhone remote, a lightweight Spotify player, and, most importantly, ship a built-in Matrix screensaver.</p>
<p>The first annoyance is the couch and the projector. Your MacBook is plugged into an HD projector over HDMI, you are lying under a blanket watching a movie, and suddenly you need to pause, turn the volume down, switch to an external audio track buried in a nested subfolder, or start the next episode. Getting up from the couch and walking over to the laptop every single time is a crime against laziness.</p>
<p>The second annoyance is Apple&#x27;s memory pricing. My MacBook Pro (<code>MacBookPro18,2</code>, Apple Silicon) has 32 GB of unified memory, and the official <code>Spotify.app</code> desktop client happily devours around 2 GB of it just for the privilege of playing music in the background.</p>
<p>In my short release post, I wrote:</p>
<blockquote><p>Memory is so expensive these days. My MacBook only has 32 GB, and Spotify takes up 2 GB of it. So I decided to rewrite the Spotify client in Rust. Now it uses 196 MB and three times less CPU.</p><p>The app is open source, of course. It also has a built-in video player with iPhone remote control.</p><p>Since Apple&#x27;s review process can now take 2-3 weeks, the app is currently available for Mac download only.</p><p>Now, when I watch videos on a projector through HDMI, I can control everything from my iPhone and hardly have to get up at all. Hehe.</p></blockquote>
<p>The Rust line came from the first, now legacy, version of the app. Here is the full, unabridged engineering breakdown of the current one. The player itself and its interface are written in native <strong>Swift 6 and SwiftUI</strong> (plus a tiny watchdog reaper process in C), and in our quest for a lightweight audio engine we went through an entire zoo of Spotify clients—from the standard Web API and studying the Rust <code>librespot</code> crate to Spotify&#x27;s brand-new official headless binary <strong>Spotify Soloist</strong>, which came out just days ago and uses 25–27 MiB of RAM.</p>
<p>Here is a short live video demo showing how it all works together:</p>
<div class="video-callout"><span class="video-callout-label"><i class="ic ic-play"></i>Video Demonstration on YouTube</span><a class="btn btn-fire" href="https://youtu.be/KmuJr8mQhhk">Watch Demo (https://youtu.be/KmuJr8mQhhk)</a></div>
<p>Let&#x27;s dive in.</p>
<h2 id="the-couch-hdmi-and-why-existing-players-didn-t-cut-it">The Couch, HDMI, and Why Existing Players Didn&#x27;t Cut It</h2>
<figure class="article-figure"><img src="https://kinrox.app/assets/img/blog/kinrox-projector-remote.webp" alt="Watching a movie on an HDMI projector and controlling playback from an iPhone on the couch" loading="lazy"><figcaption>Watching a movie on an HDMI projector and controlling playback from an iPhone on the couch</figcaption></figure>
<p>For the video player, the goal looked straightforward: a minimalist macOS player that effortlessly plays heavy <code>MKV</code> and <code>MP4</code> files, isn&#x27;t cluttered with a hundred buttons, and is controlled from a native iOS app over the local network. No cloud, no accounts, no relay servers, and no telemetry.</p>
<p>You might ask: what about <code>IINA</code> and <code>VLC</code>? <code>IINA</code> doesn&#x27;t have an official native iPhone remote app, and <code>VLC</code>&#x27;s password-protected web interface in a mobile browser tab is the last thing you want to squint at in a dark room from the couch.</p>
<p>We took the battle-tested <code>libmpv 0.41.0</code> core (<code>FFmpeg 9.0.1</code>, OpenGL, and hardware decoding via <code>VideoToolbox</code>), wrapped it behind a clean Swift <code>PlayerEngine</code> protocol so C types (<code>mpv_handle</code>, <code>mpv_event</code>) never leak outside the <code>MPVKit</code> module, and built the <code>PlayerMac</code> + <code>RemoteiOS</code> pair.</p>
<p>Here is how the remote works with zero manual setup: The Mac player starts a local server via <code>Network.framework</code> and advertises itself on the subnet over Bonjour → the iPhone discovers the Mac immediately → on first pairing, the laptop displays a 6-digit PIN code → the apps perform an authenticated Diffie-Hellman handshake using <code>CryptoKit</code> (<code>X25519</code> + <code>HKDF-SHA256</code> + <code>ChaCha20-Poly1305</code>), store the token in the system Data Protection Keychain, and exchange encrypted JSON frames over a local WebSocket.</p>
<p>Along the way, we fixed three things that constantly annoy you when watching multi-episode series folders:</p>
<ul><li><strong>Recursive discovery of external audio tracks and subtitles.</strong> If dubbed audio tracks and <code>.ass</code>/<code>.srt</code> subtitles live in sibling nested subfolders, Kinrox finds them automatically and remembers your folder-level choice for the next episode.</li><li><strong>Live folder playlist with natural Finder sort order (<code>2</code> comes before <code>10</code>).</strong> While you are watching Episode 1 and Episode 2 finishes downloading into the same folder, the <code>Next</code> button on your iPhone lights up automatically. Temporary incomplete download suffixes like <code>.part</code>, <code>.crdownload</code>, and <code>.!qB</code> are ignored until the file is complete.</li><li><strong>A unified <code>PlaybackCoordinator</code> (&quot;one audio source at a time&quot;).</strong> Hit <code>Play</code> on a movie and music pauses automatically; start a track and video pauses cleanly.</li></ul>
<p>The video player came together in five days (releases <code>0.1.0</code> through <code>0.3.0</code>). And then our eyes landed on Activity Monitor, where the 2 GB <code>Spotify.app</code> was sitting right next to a modest Kinrox process—while we still had unused tokens on our account.</p>
<h2 id="the-illusion-of-an-easy-win-why-spotify-has-so-many-clients-and-how-we-built-a-memory-saver-that-required-spotify-app-to-stay-open">The Illusion of an Easy Win: Why Spotify Has So Many Clients, and How We Built a &quot;Memory Saver&quot; That Required Spotify.app to Stay Open</h2>
<figure class="article-figure"><img src="https://kinrox.app/assets/img/blog/kinrox-spotify-music.webp" alt="Spotify Music mode in Kinrox: three dense columns without giant album art" loading="lazy"><figcaption>Spotify Music mode in Kinrox: three dense columns without giant album art</figcaption></figure>
<p>Now let&#x27;s talk about how &quot;let&#x27;s quickly bolt on music over the weekend&quot; turned into a multi-part detective investigation.</p>
<p>Originally, the plan seemed bulletproof: build a classic Winamp-style three-column layout in the <code>Music</code> tab (<code>Source -&gt; Albums -&gt; Tracks</code>, no giant half-screen album covers, everything dense and keyboard-fast), authenticate via OAuth PKCE with the user&#x27;s own <code>Client ID</code>, and play music. The #1 rule we wrote on the very first line of the specification was: <strong>no <code>WKWebView</code>, hidden browsers, or embedded Chromium runtimes anywhere in the app</strong>. Otherwise, fighting a 2 GB Electron client loses all meaning.</p>
<p>When you start digging into the Spotify ecosystem in 2026, you quickly discover that there are actually four very different kinds of &quot;Spotify clients,&quot; each with its own trap:</p>
<ol><li>The official <strong>Spotify Web API</strong> (<code>/v1/me/player</code>, i.e., Spotify Connect) cannot decode audio streams by itself. It only sends remote-control commands to an already active playback device on the network.</li><li>The browser-based <strong>Web Playback SDK</strong> requires an EME/DRM browser environment—meaning the exact <code>WebView</code> we banned at the door.</li><li>Unofficial reverse-engineered protocol clients (<code>librespot</code> in Rust, <code>go-librespot</code>) can decode audio directly, but bring protocol maintenance and platform policy headaches.</li><li>Finally, <strong>Spotify Soloist</strong> (<code>com.spotify.soloist</code>)—a brand-new official headless client from Spotify itself for Apple Silicon and Core Audio, controlled over a local WebSocket.</li></ol>
<p>At first (in release <code>0.4.0</code>), we took the first, most &quot;by-the-book&quot; route: the official Web API and Spotify Connect. We built OAuth, synced the library, shipped the build... and immediately realized the comedy of what we had done.</p>
<p>For <code>SpotifyConnectMusicEngine</code> to produce a single sound through <code>/v1/me/player</code>, an active Spotify Connect receiver had to be running on the Mac. Meaning <strong>the official <code>Spotify.app</code> had to be open!</strong></p>
<p>Appreciate that level of engineering for a second: we started this whole journey to kill the memory-hungry <code>Spotify.app</code> and free up 2 GB of RAM, and in v1 we built... an external dashboard for it that threw <code>SpotifyError.noDevice</code> and politely asked you to launch the official desktop client if <code>Spotify.app</code> was closed. <del>Great plan, flawless execution.</del></p>
<p>Even acting as a remote controller for <code>Spotify.app</code>, that first implementation taught us three painful lessons.</p>
<h3 id="pitfall-1-the-http-204-that-silently-kills-playback">Pitfall #1: The HTTP 204 That Silently Kills Playback</h3>
<p>According to the Spotify Web API documentation, if you want to play a custom queue of tracks, you can pass an array of <code>uris</code> to <code>PUT /v1/me/player/play</code>. So we did. The server cheerfully replied <code>HTTP 204 No Content</code>, after which the desktop Spotify client... simply cleared its player state and went dead silent. Any subsequent command failed with <code>403 Restriction violated</code>.</p>
<p>It turned out to be a quirk of the current desktop client when fed a raw URI list. We had to engineer a workaround: create a private technical playlist called <code>Kinrox Queue</code> in the user&#x27;s account, mirror the local queue there, and start playback inside that playlist&#x27;s context.</p>
<h3 id="pitfall-2-one-track-and-an-eternal-freeze-from-a-polling-race-condition">Pitfall #2: One Track and an Eternal Freeze from a Polling Race Condition</h3>
<p>We shipped <code>0.4.0</code> and started an album. The first song played fine, and then the Kinrox UI froze solid on the old track and stopped responding to buttons.</p>
<p>Our investigation in <code>0.4.1</code> uncovered a textbook async race condition:</p>
<ol><li>Because the Web API has no push notifications, the engine polled <code>GET /v1/me/player</code> every 2 seconds.</li><li>While an in-flight poll request from the previous state was still hanging in the air, the user clicked <code>Play</code> on a new album.</li><li>The <code>Play</code> command completed, confirmed the new queue, and reset <code>isCommandRunning</code> back to <code>false</code>.</li><li>Right at that moment, the stale poll response from step 2 finally arrived (carrying the old context—or a transient <code>HTTP 503</code>).</li><li>Seeing a foreign context while <code>isCommandRunning == false</code>, the engine concluded that playback had been hijacked externally, threw an error, and wiped the polling task (<code>polling = nil</code>). Nobody spawned a new polling loop because the old one was still marked alive when <code>Play</code> started.</li></ol>
<p>We fixed this by introducing a monotonic generation counter (<code>commandGeneration</code>): every user command increments the epoch, and any poll response carrying an older generation number is discarded on arrival.</p>
<h3 id="pitfall-3-why-skipping-a-track-fired-11-http-requests-and-the-library-took-forever-to-load">Pitfall #3: Why Skipping a Track Fired 11 HTTP Requests and the Library Took Forever to Load</h3>
<p>When we inspected the <code>0.4.1</code> network log under a microscope, our hair stood on end.</p>
<p>First, on every cold start, the app fetched the entire library from scratch strictly sequentially: all pages of liked tracks → all saved albums → all followed artists → the playlist list → all tracks of every editable playlist. Until that freight train reached the end of the line, the albums screen waited. Worst of all, <code>GET /v1/me/albums</code> already returns up to 50 tracks embedded inside each album (<code>SpotifyAlbum.tracks</code>), yet our parser simply threw them away and made 2 extra HTTP requests every time you clicked an album! Meanwhile, in-memory catalog merging ran <code>O(N²)</code> linear <code>first(where:)</code> scans across thousands of tracks right on <code>@MainActor</code>.</p>
<p>Second, whenever the user pressed <code>Next</code>, <code>Previous</code>, or even a simple <code>Resume</code> after a pause, the engine ran the full <code>startQueue</code> cycle: <strong>9 to 11 sequential HTTP requests</strong> (fetch devices → read player state → pause → confirm pause → check the <code>Kinrox Queue</code> playlist → overwrite its tracks → disable shuffle → set repeat mode → call <code>play</code> → wait for confirmation). And the confirmation check slept on the same fixed <strong>2-second interval</strong>! Even adding a single track to the queue on the fly (<code>addToQueue</code>) paused the music, rewrote the entire playlist, and restarted the current track. Background polling every 2 seconds burned <strong>1,800 requests per hour</strong>, flirting constantly with <code>HTTP 429</code> and Spotify Development Mode rate limits.</p>
<p>In <code>0.4.2</code>, we tore all of that out by the roots:</p>
<ul><li>Built a local <code>SQLite</code> cache (<code>libsqlite3</code> inside a dedicated serialized actor <code>SpotifyCacheStore</code>, 5 compact tables without bloated join tables, capped at 100 MiB). On startup, a 10,000-track library now opens from disk in <strong>40.98 ms</strong> (<strong>16.65 ms</strong> for 100 tracks) with zero network requests.</li><li>Replaced linear scans in <code>MusicCatalog</code> with <code>O(1)</code> dictionary lookups: selecting a 10,000-track playlist dropped from <strong>138.61 ms to 2.08 ms</strong> (nearly 67× faster).</li><li>Implemented three-wave loading (<code>Wave 0</code> instant disk cache → <code>Wave 1</code> albums with their embedded tracks → <code>Wave 2/3</code> background delta sync via a priority request scheduler where playback commands always preempt sync) plus <code>ETag</code> / <code>HTTP 304</code> support.</li><li>Killed periodic polling completely. While paused, Kinrox now makes <strong>0 background requests</strong>; during playback, it advances the scrub bar locally using <code>ContinuousClock</code> and schedules a single one-shot timer for the expected end-of-track boundary (<code>duration - position + 0.35 s</code>).</li><li>Added fast paths for <code>Resume</code>, <code>Next</code>, and <code>Previous</code> by offset, incremental queue insertion via <code>POST /v1/playlists/{id}/items</code> without interrupting playback, and fast confirmation backoff (<code>80 → 150 → 300 ms</code>).</li></ul>
<p>The app was now blazing fast—but the elephant in the room remained: the 2 GB <code>Spotify.app</code> still had to be running next to it. It was time for surgery.</p>
<h2 id="moving-to-spotify-soloist-fix-one-thing-blow-up-another">Moving to Spotify Soloist: Fix One Thing, Blow Up Another</h2>
<p>For release <code>0.5.0</code>, our acceptance criterion was uncompromising: <code>Spotify.app</code> had to be completely terminated (<code>pgrep -x Spotify</code> must return nothing throughout the entire test). No hidden windows, no embedded browser runtimes.</p>
<p>We evaluated our options and chose <strong>Spotify Soloist</strong> (<code>com.spotify.soloist</code>)—Spotify&#x27;s new official headless binary for macOS (<code>arm64</code>, Core Audio). Under Spotify&#x27;s terms, Soloist cannot be redistributed inside a third-party <code>.dmg</code>, so during setup Kinrox downloads the archive directly from Spotify&#x27;s official CDN, verifies its Apple Developer ID signature (<code>Team ID 2FNC3A47ZF</code>) and notarization via <code>SecStaticCodeCheckValidity</code>, unpacks it into an isolated Application Support directory, and talks to it over a local WebSocket on <code>127.0.0.1</code>. You pair it once from the Spotify app on your phone over Wi-Fi, close Spotify everywhere, and audio plays directly through Core Audio.</p>
<p>And that&#x27;s when the real thriller began.</p>
<h3 id="episode-1-a-5-minute-track-ends-and-immortal-autoplay-crashes-the-party">Episode 1: A 5-Minute Track Ends, and Immortal Autoplay Crashes the Party</h3>
<p>We started <code>Soloist</code> in its default persistent mode. It was critical for Kinrox to own 100% of the playback queue—we have our own shuffle, repeat, and history semantics. So we explicitly disabled <code>Autoplay</code> in Spotify settings on the iPhone and sent <code>shuffle: false</code> and <code>repeat: &quot;off&quot;</code> over the WebSocket to <code>Soloist</code>.</p>
<p>We loaded a long <strong>323-second</strong> track (over 5 minutes) and patiently waited for it to reach natural <code>EOF</code>, expecting Kinrox to advance cleanly to the next track in our local queue.</p>
<p>Instead, at second 324, <code>Soloist</code> cheerfully ignored every disabled toggle and blasted a random recommended song tagged <code>source: autoplay</code>! It turned out that in persistent mode, once a single track URI finishes, the internal engine reaches out for Spotify autoplay recommendations regardless of your local queue plans.</p>
<h3 id="episode-2-enabling-single-track-and-planting-a-60-second-time-bomb">Episode 2: Enabling <code>--single-track</code> and Planting a 60-Second Time Bomb</h3>
<p>Fine—if persistent mode refuses to obey, we switch <code>Soloist</code> to <code>--single-track</code> mode: one process per track in our queue. It plays the song → exits cleanly with <code>exit code 0</code> → our state reducer verifies natural track completion and launches the next track. Autoplay defeated!</p>
<p>Then we pressed pause to go make tea. Exactly one minute passed. At <strong>60.6 seconds</strong> (<strong>61.75 seconds</strong> on the second verification run), the paused <code>soloist</code> process silently died with <code>exit code 1</code>! The transport buttons in the player turned into a pumpkin.</p>
<p>Back into the trace logs we went: in <code>--single-track</code> mode, the daemon treats any pause longer than ~60 seconds as an abandoned session and aborts.</p>
<p>How do you let a user pause music for an hour—or a full day—when the underlying process self-destructs after 60 seconds? We designed a <strong>20-second parking mechanism</strong>:</p>
<ul><li>During a short pause (under 20 seconds), the <code>soloist</code> process stays warm, and <code>Resume</code> fires in <strong>11–22 ms</strong>.</li><li>As soon as a pause crosses <strong>20 seconds</strong> (well before the fatal 60-second mark!), Kinrox records the exact confirmed millisecond position and cleanly terminates the child <code>soloist</code> process. As a result, during a long pause, the audio engine consumes <strong>0 MB of RAM and 0% CPU</strong>.</li><li>When you come back half an hour later and hit <code>Space</code>, Kinrox spawns a fresh <code>soloist</code> process at <code>0%</code> volume (so you never hear a split-second burst from the start of the song), seeks to the saved millisecond offset, waits for decoder confirmation, and only then restores your target volume. Full recovery takes <strong>1.6–2.1 seconds</strong>.</li></ul>
<p>Even there, we managed to step on a rake: initially, the parking timer only armed when pause was triggered inside Kinrox itself. During a live test, a pause event arrived externally, the 20-second timer didn&#x27;t arm, and 60 seconds later <code>soloist</code> proudly exited with code 1 again. Fixed: now any observed <code>paused</code> state transition arms the parking timer.</p>
<h3 id="episode-3-failed-to-open-video-spotify-said-no">Episode 3: &quot;Failed to Open Video: Spotify Said No&quot;</h3>
<p>Music was finally playing with <code>Spotify.app</code> closed. We decided to run a sanity check on the regular video player. We opened a local <code>.mkv</code> movie file from disk... and got hit with a giant red error banner:</p>
<p><code>Failed to open video / Soloist could not confirm the playback command.</code></p>
<p>Picture that scene: you open a local video file in a video player, and it refuses because the Spotify music engine didn&#x27;t confirm a command!</p>
<p>The cause was hilarious: before opening a movie, our <code>PlaybackCoordinator</code> dutifully enforced the &quot;one audio source at a time&quot; rule and asked the music engine to pause. When the new <code>SoloistMusicEngine</code> received <code>setPaused(true)</code>, it checked whether there was an active track (<code>occurrenceID</code>) in the queue, saw <code>nil</code> (nobody had played any music since launching the app!), and threw an unconfirmed-command error that aborted opening the video.</p>
<h3 id="episode-4-orphans-after-sigkill-and-shipping-with-0-default-volume">Episode 4: Orphans After <code>SIGKILL</code> and Shipping With <code>0%</code> Default Volume</h3>
<p>Two more surprises surfaced right after.</p>
<p>First, the orphaned child process problem on hard kill. Because <code>Kinrox</code> spawns <code>soloist</code> as a child process, a normal quit shuts it down cleanly. But if <code>Kinrox.app</code> is killed with <code>kill -9</code> (<code>SIGKILL</code>), the child <code>soloist</code> process becomes an orphan and keeps playing music out of nowhere with no visible window on screen. To eliminate this, we wrote a tiny watchdog reaper in pure C—<code>KinroxSoloistHost</code> (53 KB on disk, 0.94 MiB in memory, 0% CPU). It receives zero keys or tokens—only an inherited <code>pipe FD</code>, the <code>soloist</code> <code>PID</code>, its start timestamp, and the binary path. If <code>Kinrox</code> dies suddenly from <code>SIGKILL</code>, the OS kernel closes the write end of the pipe, <code>KinroxSoloistHost</code> wakes up on <code>EOF</code>, verifies <code>proc_pidpath</code> and the process start time (so it can never kill an unrelated process if the OS recycles the PID), and terminates <code>soloist</code> within <strong>139.7 ms</strong>.</p>
<p>Second, the silent-install comedy in <code>0.5.0</code>. To run thousands of automated tests (including a 30-minute soak test and a 1,003-command stress test from a physical iPhone) without blasting our eardrums, our repository rules require every test to run with <code>gain = 0</code> (<code>0%</code> player volume). We got so used to seeing <code>confirmed gain zero</code> in the test logs that we accidentally shipped <code>0.5.0</code> with <code>0%</code> as the default initial volume for fresh installations! You hit Play, the scrub bar moved, everything worked—and the speakers were dead silent. In <code>0.5.1</code>, we restored a <code>100%</code> default and persisted user volume across sessions.</p>
<h2 id="the-final-boss-0-5-2-why-does-soloist-use-27-mb-while-our-lightweight-swiftui-uses-342-mb-and-94-of-a-core">The Final Boss (<code>0.5.2</code>): Why Does <code>soloist</code> Use 27 MB While Our &quot;Lightweight&quot; SwiftUI Uses 342 MB and 94% of a Core?</h2>
<p>Once the entire playback pipeline was running like clockwork in <code>0.5.1</code>, we opened <code>top</code>, <code>vmmap</code>, and <code>sample</code> to admire all the memory we had saved.</p>
<p>On <code>soloist</code>&#x27;s side, the numbers were a dream: <strong>24.7–27.3 MiB</strong> of physical footprint and <strong>0.7–1.06% of one CPU core</strong> during active playback.</p>
<p><code>Kinrox.app</code> itself in Music mode, however, suddenly showed <strong>342 MB of memory</strong> with a constantly blinking plus sign (<code>342M+</code> in <code>top</code>) and burned up to <strong>93.96% of a CPU core</strong> on initial runs (and after our first timer patch, still a steady <strong>7–10% CPU</strong> even when minimized)!</p>
<p>A quick aside on macOS <code>top</code>: the <code>+</code> suffix next to memory (<code>342M+</code>) is often mistaken for an unbounded memory leak. In reality, our 20-minute run under <code>leaks</code> showed a flat plateau of <code>341.9–342.5 MB</code> and just 224 bytes of leaked memory across 2 million allocations. The plus sign in <code>top</code> simply means the allocator grew slightly between two 1-second samples due to constant creation and destruction of temporary SwiftUI buffers. That allocation churn wasn&#x27;t leaking RAM, but it was torching the CPU for no reason.</p>
<p>We captured <code>sample</code> and <code>heap</code> profiles and uncovered two classic traps that bite almost everyone building desktop macOS apps in SwiftUI.</p>
<h3 id="cause-1-a-717-kb-database-turns-into-755-262-attributegraph-nodes">Cause 1: A 717 KB Database Turns Into 755,262 <code>AttributeGraph</code> Nodes</h3>
<p>On disk, our entire SQLite library database (837 albums, 880 artists, 1,099 tracks, and 27 playlists) weighs <strong>717 KB</strong> (around 2–3 MB in decoded Swift structs). Where on earth were the extra 225 MB of heap (<code>Malloc</code> + <code>AttributeGraph</code>) and <strong>2,003,806 live allocations</strong> coming from?!</p>
<p>The culprit was hiding in a single line of the left sidebar (<code>SourceColumn.swift</code>). The list of sources and all 880 artists was wrapped inside a <code>ScrollView</code> using a regular, eager <strong><code>VStack</code></strong> instead of a <strong><code>LazyVStack</code></strong>!</p>
<p>Here is what SwiftUI did the moment the window opened:</p>
<ul><li>Eagerly instantiated all <strong>880 artist rows</strong> at once, even though a 220 pt sidebar only shows about 20 rows on screen.</li><li>Every artist row contained two buttons (selection <code>Button</code> + favorite <code>MusicHeart</code>) and two <code>.help(...)</code> tooltip modifiers.</li><li>That single <code>VStack</code> spawned <strong>1,760 of the 1,965 total <code>Button</code> instances in the entire application (89.6%)</strong>, <strong>84,342 <code>HelpStyleConfiguration</code> nodes</strong>, and <strong>1,760 AppKit tracking areas (<code>_NSTrackingAreaAKViewHelper</code>)</strong>!</li><li>On top of that, every row in the track table (<code>MusicTrackRow</code>) wrapped itself in its own <code>GeometryReader</code>, unconditionally attached a <code>.popover(LikePopover)</code>, and registered a <code>.dropDestination</code> even in views where drag-and-drop reordering was disabled.</li></ul>
<h3 id="cause-2-four-ticks-per-second-for-a-whole-second-timer">Cause 2: Four Ticks Per Second for a Whole-Second Timer</h3>
<p>Why was the CPU on fire at the same time? In the bottom playback deck (<code>DeckView</code>), a timer loop updated the fractional <code>clock.position</code> property on an <code>@Observable</code> model every <strong>250 ms (4 Hz)</strong>.</p>
<p>Yet the time label on screen displays <strong>whole seconds</strong> (<code>1:04</code> → <code>1:05</code>)! That meant 3 out of every 4 ticks per second were waking up SwiftUI to render the exact same string of text—rebuilding <code>AccessibilityAttachment</code> dictionaries and forcing the AppKit wrapper behind <code>SwiftUI.Slider</code> (<code>NSSlider</code>) to invalidate its layout. And because the text timer didn&#x27;t have fixed width and height bounds, every 250 ms tick dirtied the root <code>GeometryReader</code> of the entire window and forced SwiftUI/AppKit to traverse that bloated graph of <strong>755,262 nodes</strong> and 2,084 tracking areas! Worse still, it kept hammering away even when the player window was completely occluded behind other apps or minimized to the Dock.</p>
<p>In release <code>0.5.2</code>, we fixed both bottlenecks:</p>
<ol><li>Replaced <code>VStack</code> with <code>LazyVStack</code> in the source column, stripped redundant row-level <code>.help</code> modifiers, hoisted <code>GeometryReader</code> out of individual track rows up to the table container, and attached <code>.popover</code> and <code>.dropDestination</code> only to the active row.</li><li>Replaced the heavy AppKit-backed <code>SwiftUI.Slider</code> (<code>NSSlider</code>) with a lightweight custom scrubber built from native <code>Capsule</code> + <code>Circle</code> primitives.</li><li>Quantized deck clock updates to <strong>whole seconds (1 Hz instead of 4 Hz)</strong>, locked text timers inside fixed frames, and subscribed to <code>NSWindow.didChangeOcclusionStateNotification</code>: whenever the window is hidden behind other apps or minimized, the UI timer sleeps completely (<code>0</code> wakeups).</li></ol>
<p>Memory footprint immediately dropped by roughly 150 MB (down to ~180–196 MB in active use versus 2 GB for the official Spotify client), live heap allocations fell from 2 million to under 350,000, and CPU usage dropped to <strong>&lt;1% of a single core</strong> with the window visible and <strong>~0%</strong> in the background.</p>
<h2 id="and-of-course-we-couldn-t-ship-without-the-matrix-screensaver">And of Course, We Couldn&#x27;t Ship Without the Matrix Screensaver!</h2>
<figure class="article-figure"><img src="https://kinrox.app/assets/img/blog/kinrox-matrix-screensaver.webp" alt="The most important feature in Kinrox: the Matrix screensaver" loading="lazy"><figcaption>The most important feature in Kinrox: the Matrix screensaver</figcaption></figure>
<p>You know the unwritten law of software engineering: if you are building your own app and nobody can stop you, it <strong>must</strong> include a Matrix screensaver. Starting in <code>0.3.0</code>, Kinrox has one both on the Mac (<code>Screensaver → Matrix</code>) and as a dedicated button right on the iPhone remote.</p>
<p>Here is how it works: you&#x27;re watching a movie on the projector, decide to step away for tea, and tap <code>Matrix</code> on your iPhone. Kinrox first waits for hardware pause confirmation from <code>libmpv</code>, smoothly fades out the exit hint after 3 seconds (and mouse movement deliberately does not bring the hint back, so nothing spoils the projection on your wall), and renders falling green glyph streams with bright leading katakana characters at 30 fps on a <code>SwiftUI Canvas</code> right over the paused frame. Press <code>Esc</code> on the Mac or tap <code>Stop</code> on the iPhone, and you&#x27;re right back on the exact paused frame without getting kicked out of native fullscreen.</p>
<p>Even the screensaver had a fun bug: originally, <code>MatrixRainView</code> relied on a standard <code>TimelineView</code> tied to SwiftUI&#x27;s <code>scenePhase</code>. When we tested it inside an <code>NSHostingView</code> whose window reported a background <code>scenePhase</code> (a classic multi-monitor setup where the projector is your second display and you clicked into another app on your laptop), the green rain froze on frame one! We decoupled the Canvas timeline from <code>scenePhase</code>, wrote a regression test that renders two consecutive frames in a backgrounded window and verifies byte-for-byte that the glyphs actually moved, and taught <code>PlayerWindowController</code> to intercept the first <code>Esc</code> press before AppKit could exit native fullscreen.</p>
<h2 id="benchmarks-and-numbers-where-we-landed">Benchmarks and Numbers: Where We Landed</h2>
<p>Here is the full summary of our measurements. Test environment: <code>MacBookPro18,2</code> (Apple Silicon, 32 GB RAM, macOS 26.5.2), release builds of <code>Kinrox 0.5.0–0.5.5</code> and <code>Spotify Soloist 1.3.10.36</code>.</p>
<div class="article-table-wrap"><table class="article-table"><thead><tr><th>Metric</th><th>Before / Alternative</th><th>In Kinrox (0.5.2–0.5.5)</th><th>Verification</th></tr></thead><tbody><tr><td>Memory footprint (<code>Spotify.app</code> vs <code>Kinrox + Soloist</code>)</td><td>~2,000 MB (<code>Spotify.app</code>)</td><td>~180–196 MB (<code>Kinrox</code>) + <strong>24.7–27.3 MiB</strong> (<code>soloist</code>) + <strong>0.94 MiB</strong> (<code>reaper</code>)</td><td>Verified (30-min soak test &amp; <code>vmmap</code>)</td></tr><tr><td>CPU usage during music playback</td><td>93.96% core (initial UI run) / 7–10% (pre-<code>0.5.2</code>)</td><td><strong>&lt;1%</strong> for <code>Kinrox</code> on screen (<strong>~0%</strong> in background) + <strong>0.95–1.06%</strong> for <code>soloist</code></td><td>Verified (<code>sample</code> + <code>proc_pid_rusage</code>)</td></tr><tr><td><code>soloist</code> usage during long pause (&gt;20 s)</td><td>Crashed with <code>exit code 1</code> at 60 seconds</td><td><strong>0 MB RAM, 0% CPU</strong> (process parked, millisecond position saved)</td><td>Verified</td></tr><tr><td>Background Web API requests (play / pause)</td><td>1,800 req/hr (playing) &amp; 360 req/hr (paused)</td><td><strong>0 periodic requests</strong> (local monotonic clock + 1 boundary check per track)</td><td>Verified</td></tr><tr><td>Opening cached library (10,000 tracks)</td><td>Full sequential network pagination on launch</td><td><strong>40.98 ms</strong> (p95) from SQLite on disk, <strong>0 HTTP requests</strong></td><td>Verified (benchmark harness)</td></tr><tr><td>Selecting a 10,000-track playlist in memory</td><td><strong>138.61 ms</strong> (<code>O(N²)</code> linear scan)</td><td><strong>2.08 ms</strong> (<code>O(1)</code> dictionary index in <code>MusicCatalog</code>)</td><td>Verified</td></tr><tr><td>Physical iPhone LAN commands (200 cycles, 1,003 commands)</td><td>9–11 HTTP requests per <code>Next</code> in <code>0.4.0</code></td><td><code>Pause</code> p50 <strong>72.5 ms</strong>, <code>Resume</code> <strong>87.0 ms</strong>, <code>Seek</code> <strong>83.0 ms</strong>, <code>Next</code> p50 <strong>2.10 s</strong> (p95 <strong>3.095 s</strong>, 0 errors)</td><td>Verified (<code>phone-200.xcresult</code>)</td></tr><tr><td>UI &amp; website localization</td><td>EN/RU only at launch</td><td><strong>30 languages</strong> (including RTL: Arabic, Hebrew, Persian, Urdu)</td><td>Verified (348 app keys &amp; 120 site pages)</td></tr></tbody></table></div>
<h2 id="three-engineering-takeaways-you-can-steal-for-your-own-macos-apps">Three Engineering Takeaways You Can Steal for Your Own macOS Apps</h2>
<p>Why did we obsess over epoch counters, <code>vmmap</code>, and a C watchdog binary in a home pet project? Because we wanted a codebase that is still a joy to maintain two years from now and doesn&#x27;t fall apart under real-world edge cases. From our bruises, three universal rules stand out for any macOS desktop app:</p>
<ol><li><strong>Quantize your UI clocks and respect window occlusion.</strong> If your SwiftUI view displays a whole-second timer or progress bar, never feed an <code>@Observable</code> model fractional updates at 4–60 Hz, and never leave text counters without a fixed <code>frame(width:height:)</code>. Quantize updates to the smallest visible unit (once per second) and put your UI timer to sleep via <code>NSWindow.didChangeOcclusionStateNotification</code> whenever the window is hidden behind other apps.</li><li><strong>Never trust <code>HTTP 204</code> or command submission as state proof.</strong> When driving an external asynchronous engine (<code>libmpv</code>, a Web API, or a local WebSocket daemon), maintain a monotonic epoch counter (<code>sessionEpoch</code> / <code>commandID</code>) and transition UI state only after receiving a confirmed event from the engine itself, discarding any responses from older epochs.</li><li><strong><code>terminationHandler</code> won&#x27;t save you from <code>SIGKILL</code>.</strong> If your app spawns a child daemon, a tiny secret-free C watchdog binary that blocks on an inherited <code>pipe FD</code> and checks <code>proc_pidpath</code> + process start time before sending <code>SIGTERM</code> solves the orphaned-process problem once and for all.</li></ol>
<h2 id="trade-offs-and-current-limitations">Trade-offs and Current Limitations</h2>
<p>To set clear expectations, here are the current boundaries and trade-offs:</p>
<ul><li><strong>Music mode requires Spotify Premium, your own <code>Client ID</code>, and a Soloist key.</strong> Because of Spotify Development Mode quota rules, we cannot bundle a shared <code>Client ID</code> for everyone. In Kinrox Settings, you paste your own <code>Client ID</code> from the Spotify Developer Dashboard, grab a personal key from <a href="https://developer.spotify.com/dashboard/soloist">Spotify Soloist</a>, and pair the engine once with your phone. The video player and iPhone movie remote work 100% offline with zero keys or accounts.</li><li><strong>The <code>Soloist</code> binary expires after 90 days.</strong> This is a built-in Spotify limitation: 90 days after a build is created, the binary exits with <code>exit code 10</code>. Kinrox detects this exit code and offers a one-click engine update right inside Settings.</li><li><strong>Skipping a track (<code>Next</code>) takes ~2 seconds (p50 = 2.1 s, p95 = 3.1 s).</strong> Because we run an isolated <code>--single-track</code> process per queue item to prevent unwanted autoplay and verify the binary&#x27;s code signature before each launch, there is no gapless transition between tracks yet.</li><li><strong>What about the iPhone app?</strong> While Apple&#x27;s App Store review process can currently take <strong>2–3 weeks</strong>, the signed and notarized <code>.dmg</code> is available right now for Mac (Apple Silicon, macOS 14+). The complete <code>RemoteiOS</code> source code is in the same repository—you can build and install it onto your iPhone in Xcode in a couple of minutes (and it even includes a <code>-KinroxDemo</code> flag if you want to test the remote UI without a Mac).</li></ul>
<h2 id="wrap-up">Wrap-Up</h2>
<p>Kinrox grew out of a simple desire not to get up from the couch to pause a projector movie—and to finally have a native Spotify player on macOS that feels snappy, includes a Matrix screensaver, and doesn&#x27;t park 2 GB of Electron baggage in RAM.</p>
<p>The project is open (the Mac player is <code>GPL-3.0-only</code>, and the iPhone remote source is available for personal non-commercial use and forks):</p>
<ul><li>Website &amp; signed Mac DMG download: <a href="https://kinrox.app/">https://kinrox.app/</a></li><li>Source code, documentation, and all engineering investigations in <code>docs/RESEARCHES</code>: <a href="https://github.com/awaik/Kinrox">https://github.com/awaik/Kinrox</a></li></ul>
<p>Grab it, use it, fork it, tweak it for your own setup, or open issues and PRs on GitHub. And I&#x27;d love to hear from you: what does your Mac home-theater setup look like, and how much RAM is your background music player eating right now?</p>]]></content:encoded>
    </item>
  </channel>
</rss>
